├── .gitignore ├── LICENSE ├── README.md ├── demo ├── demo │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── echo │ ├── __init__.py │ ├── models.py │ ├── tests.py │ └── views.py └── manage.py ├── setup.py ├── tea.yaml └── wechat ├── __init__.py ├── crypt.py ├── enterprise.py ├── models.py └── official.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | build 3 | dist 4 | *.egg-info 5 | *.pyc 6 | *.db 7 | .idea 8 | 9 | ### Django ### 10 | *.log 11 | *.pot 12 | *.pyc 13 | __pycache__/ 14 | local_settings.py 15 | media/ 16 | .vscode/ 17 | 18 | .idea/* 19 | 20 | ### sublime ### 21 | *.sublime-project 22 | *.sublime-workspace 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. [http://fsf.org/] 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | {one line to give the program's name and a brief idea of what it does.} 635 | Copyright (C) {year} {fullname} 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see [http://www.gnu.org/licenses/]. 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | wechat Copyright (C) 2014 jeff 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | [http://www.gnu.org/licenses/]. 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | [http://www.gnu.org/philosophy/why-not-lgpl.html]. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 微信公众号Python-SDK 2 | 3 | 作者: [@jeff_kit](http://twitter.com/jeff_kit) 4 | 5 | 本SDK支持微信公众号以及企业号的上行消息及OAuth接口。本文档及SDK假设使用者已经具备微信公众号开发的基础知识,及有能力通过微信公众号、企业号的文档来查找相关的接口详情。 6 | 7 | 8 | ## 1. 安装 9 | 10 | ### pip 11 | 12 | pip install wechat 13 | 14 | ### 源码安装 15 | 16 | git clone git@github.com:jeffkit/wechat.git 17 | cd wechat 18 | python setup.py install 19 | 20 | 21 | ## 2. 用户上行消息处理框架 22 | 23 | 对于微信用户在公众号内发送的上行消息,本sdk提供了一个微型处理框架,开发者只需继承wechat.official.WxApplication类, 实现各种消息对应的方法,然后把该类与自己熟悉的web框架结合起来使用即可。 24 | 25 | WxApplication内部会实现请求的合法性校验以及消息的分发等功能,还对上行消息对行了结构化,开发者把精力放到业务逻辑的编写即可。 26 | 27 | WxApplication类核心方法: 28 | 29 | ### WxApplication.process(params, xml, token=None, app_id=None, aes_key=None) 30 | 31 | WxApplication的process函数,接受以下参数: 32 | 33 | - params, url参数字典,需要解析自微信回调的url的querystring。格式如:{'nonce': 1232, 'signature': 'xsdfsdfsd'} 34 | - xml, 微信回调时post的xml内容。 35 | - token, 公众号的上行token,可选,允许在子类配置。 36 | - app_id, 公众号应用id,可选,允许在子类配置。 37 | - aes_key, 公众号加密secret,可选,允许在子类配置。 38 | 39 | process最后返回一串文本(xml或echoStr)。 40 | 41 | 42 | #### 使用场景1:上行URL有效性验证 43 | 44 | 在微信公众号的后台设置好URL及token等相关信息后,微信会通过GET的方式访问一次该URL,开发者在URL的响应程序里直接调用app.process(params, xml=None)即可返回echStr。 45 | 46 | qs = 'nonce=1221&signature=19selKDJF×tamp=12312' 47 | query = dict([q.split('=') for q in qs.split('&')]) 48 | app = YourApplication() 49 | echo_str = app.process(query, xml=None) 50 | # 返回echo_str给微信即可 51 | 52 | 53 | #### 使用场景2:处理上行消息 54 | 55 | 用户在微信公众号上发消息给公众号,微信服务器调用上行的URL,开发者需要对每次的的请求进行合法性校验及对消息进行处理,同样的,直接调用app.process方法就好。 56 | 57 | qs = 'nonce=1221&signature=19selKDJF×tamp=12312' 58 | query = dict([q.split('=') for q in qs.split('&')]) 59 | body = ' ..... ' 60 | app = YourApplication() 61 | result = app.process(query, xml=body) 62 | # 返回result给微信即可 63 | 64 | 65 | ### WxApplication子类示例 66 | 67 | 下面先看看一个WxApplication的示例代码,用于把用户上行的文本返还给用户: 68 | 69 | from wechat.official import WxApplication, WxTextResponse, WxMusic,\ 70 | WxMusicResponse 71 | 72 | class WxApp(WxApplication): 73 | 74 | SECRET_TOKEN = 'test_token' 75 | WECHAT_APPID = 'wx1234556' 76 | WECHAT_APPSECRET = 'sevcs0j' 77 | 78 | def on_text(self, text): 79 | return WxTextResponse(text.Content, text) 80 | 81 | 82 | 需要配置几个类参数,几个参数均可在公众号管理后台的开发者相关页面找到,前三个参数如果不配置,则需要在调用process方法时传入。 83 | 84 | - SECRET_TOKEN: 微信公众号回调的TOKEN 85 | - APP_ID: 微信公众号的应用ID 86 | - ENCODING_AES_KEY: (可选),加密用的SECRET,如您的公众号未采取加密传输,不需填。 87 | - UNSUPPORT_TXT:(可选),收到某种不支持类型的消息时自动响应给用户的文本消息。 88 | - WELCOME_TXT:(可选), 新关注时默认响应的文本消息。 89 | 90 | 然后,您需要逐一实现WxApplication的各个on_xxxx函数。不同类型的上行消息及事件均有对应的on_xxx函数 91 | 92 | ### on_xxx函数 93 | 94 | 95 | 所有的on_xxx函数列举如下: 96 | 97 | - on_text, 响应用户文本 98 | - on_link,响应用户上行的链接 99 | - on_image,响应用户上行图片 100 | - on_voice,响应用户上行语音 101 | - on_video,响应用户上行视频 102 | - on_location,响应用户上行地理位置 103 | - on_subscribe,响应用户关注事件 104 | - on_unsubscribe,响应用户取消关注事件 105 | - on_click,响应用户点击自定义菜单事件 106 | - on_scan,响应用户扫描二维码事件 107 | - on_location_update,响应用户地理位置变更事件 108 | - on_view,响应用户点击自定义菜单访问网页事件 109 | - on_scancode_push 110 | - on_scancode_waitmsg 111 | - on_pic_sysphoto 112 | - on_pic_photo_or_album 113 | - on_pic_weixin 114 | - on_location_select 115 | 116 | on_xxx函数的定义如下: 117 | 118 | def on_xxx(self, req): 119 | return WxResponse() 120 | 121 | on_xxx函数,接受一个WxRequest参数req,返回一个WxResponse的子类实例。 122 | 123 | #### WxRequest 124 | 125 | req是一个代表用户上行消息的WxRequest实例。其属性与消息的XML属性一一对应,不同的消息有几个相同的属性: 126 | 127 | - ToUserName 128 | - FromUserName 129 | - CreateTime 130 | - MsgType 131 | - MsgId 132 | 133 | 不同的消息类型对应有各自的属性,属性名与消息的xml标签名保一致。如MsgType为text的的req,有一个Content属怀,而MsgType为image的req,则有PicUrl及MediaId两个属性。更多消息详情请查看微信公众号[官方文档](http://mp.weixin.qq.com/wiki/10/79502792eef98d6e0c6e1739da387346.html)。 134 | 135 | #### WxResponse 136 | 137 | on_xxx函数需要返回一个WxResponse的子类实例。WxResponse的子类及其构造的方式有: 138 | 139 | ##### WxTextResponse, 文本消息 140 | 141 | WxTextResponse("hello", req) 142 | 143 | ##### WxImageResponse, 图片消息 144 | 145 | WxImageResponse(WxImage(MediaId='xxyy'),req) 146 | 147 | ##### WxVoiceResponse, 语音消息 148 | 149 | WxVoiceResponse(WxVoice(MediaId='xxyy'),req) 150 | 151 | ##### WxVideoResponse, 视频消息 152 | 153 | WxVideoResponse(WxVideo(MediaId='xxyy', Title='video', Description='test'),req) 154 | 155 | ##### WxMusicResponse, 音乐消息 156 | 157 | WxMusicResponse(WxMusic(Title='hey jude', 158 | Description='dont make it bad', 159 | PicUrl='http://heyjude.com/logo.png', 160 | Url='http://heyjude.com/mucis.mp3'), req) 161 | 162 | ##### WxNewsResponse, 图文消息 163 | 164 | WxNewsResponse(WxArticle(Title='test news', 165 | Description='this is a test', 166 | Picurl='http://smpic.com/pic.jpg', 167 | Url='http://github.com/jeffkit'), req) 168 | ##### WxEmptyResponse, 无响应 169 | 170 | WxEmptyResponse(req) 171 | 172 | ### 在Django中使用WxApplication 173 | 174 | 175 | 下面以Django为例说明,实现一个微信回调的功能(view),利用上面示例代码中的WxApp: 176 | 177 | from django.http import HttpResponse 178 | 179 | def wechat(request): 180 | app = WxApp() 181 | result = app.process(request.GET, request.body) 182 | return HttpResponse(result) 183 | 184 | 配置 urls.py: 185 | 186 | urlpatterns = patterns('', 187 | url(r'^wechat/', 'myapp.views.wechat'), 188 | ) 189 | 190 | 191 | ### 在Flask中使用WxApplication 192 | from flask import request 193 | from flask import Flask 194 | app = Flask(__name__) 195 | 196 | @app.route('/wechat') 197 | def wechat(): 198 | app = WxApp() 199 | return app.process(request.args, request.data) 200 | 201 | 202 | OK.就这么多,WxApplication本身与web框架无关,不管你使用哪个Framework都可以享受到它带来的便利。 203 | 204 | ### 什么?你不喜欢写WxApplication的子类?! 205 | 206 | 好吧,其实,你可以在任何地方写on_xxx的响应函数。然后在使用之前,告诉一个WxApplication你要用哪个函数来响应对应的事件就好。以Django为例: 207 | 208 | # 在任何地方写你自己的消息处理函数。 209 | # @any_decorator # 添加任何装饰器。 210 | def my_text_handler(req): 211 | return WxTextResponse(req.Content, req) 212 | 213 | # 在web的程序里这样使用: 214 | def wechat_view(request): 215 | app = WxApplication() # 实例化基类就好。 216 | app.handlers = {'text': my_text_handler} # 设置你自己的处理器 217 | result = app.process(request.GET, request.body, 218 | token='xxxx', app_id='xxxx', aes_key='xxxx') 219 | return HttpResponse(result) 220 | 221 | 222 | 嗯,可以自定义消息的handlers,而如果要针对事件自定义handlers的话,要修改app.event_handlers,数据的格式是一样的。具体的消息和事件类型的key,就直接看看源码得了。卡卡。 223 | 224 | 225 | ## 3. OAuth API 226 | 227 | OAuth API目前仅支持下列常用接口: 228 | 229 | - 发送消息 230 | - 用户管理 231 | - 自定义菜单管理 232 | - 多媒体上传下载 233 | - 二维码 234 | 235 | 其他接口拟于未来的版本中支持,同时欢迎大家来增补。 236 | 237 | -------------------------------------------------------------------------------- /demo/demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffkit/wechat/ab5147c1d20c1a705cd257b519f9930e2fc6e12f/demo/demo/__init__.py -------------------------------------------------------------------------------- /demo/demo/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for demo project. 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = DEBUG 5 | 6 | ADMINS = ( 7 | # ('Your Name', 'your_email@example.com'), 8 | ) 9 | 10 | MANAGERS = ADMINS 11 | 12 | DATABASES = { 13 | 'default': { 14 | 'ENGINE': 'django.db.backends.', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. 15 | 'NAME': '', # Or path to database file if using sqlite3. 16 | 'USER': '', # Not used with sqlite3. 17 | 'PASSWORD': '', # Not used with sqlite3. 18 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. 19 | 'PORT': '', # Set to empty string for default. Not used with sqlite3. 20 | } 21 | } 22 | 23 | # Local time zone for this installation. Choices can be found here: 24 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 25 | # although not all choices may be available on all operating systems. 26 | # In a Windows environment this must be set to your system time zone. 27 | TIME_ZONE = 'America/Chicago' 28 | 29 | # Language code for this installation. All choices can be found here: 30 | # http://www.i18nguy.com/unicode/language-identifiers.html 31 | LANGUAGE_CODE = 'en-us' 32 | 33 | SITE_ID = 1 34 | 35 | # If you set this to False, Django will make some optimizations so as not 36 | # to load the internationalization machinery. 37 | USE_I18N = True 38 | 39 | # If you set this to False, Django will not format dates, numbers and 40 | # calendars according to the current locale. 41 | USE_L10N = True 42 | 43 | # If you set this to False, Django will not use timezone-aware datetimes. 44 | USE_TZ = True 45 | 46 | # Absolute filesystem path to the directory that will hold user-uploaded files. 47 | # Example: "/home/media/media.lawrence.com/media/" 48 | MEDIA_ROOT = '' 49 | 50 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 51 | # trailing slash. 52 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 53 | MEDIA_URL = '' 54 | 55 | # Absolute path to the directory static files should be collected to. 56 | # Don't put anything in this directory yourself; store your static files 57 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 58 | # Example: "/home/media/media.lawrence.com/static/" 59 | STATIC_ROOT = '' 60 | 61 | # URL prefix for static files. 62 | # Example: "http://media.lawrence.com/static/" 63 | STATIC_URL = '/static/' 64 | 65 | # Additional locations of static files 66 | STATICFILES_DIRS = ( 67 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 68 | # Always use forward slashes, even on Windows. 69 | # Don't forget to use absolute paths, not relative paths. 70 | ) 71 | 72 | # List of finder classes that know how to find static files in 73 | # various locations. 74 | STATICFILES_FINDERS = ( 75 | 'django.contrib.staticfiles.finders.FileSystemFinder', 76 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 77 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder', 78 | ) 79 | 80 | # Make this unique, and don't share it with anybody. 81 | SECRET_KEY = '0q5ti4(tfb!a2jcqd^qjy4&@eqd2=kx05)$5+&k)25l3k7u*jd' 82 | 83 | # List of callables that know how to import templates from various sources. 84 | TEMPLATE_LOADERS = ( 85 | 'django.template.loaders.filesystem.Loader', 86 | 'django.template.loaders.app_directories.Loader', 87 | # 'django.template.loaders.eggs.Loader', 88 | ) 89 | 90 | MIDDLEWARE_CLASSES = ( 91 | 'django.middleware.common.CommonMiddleware', 92 | 'django.contrib.sessions.middleware.SessionMiddleware', 93 | 'django.middleware.csrf.CsrfViewMiddleware', 94 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 95 | 'django.contrib.messages.middleware.MessageMiddleware', 96 | # Uncomment the next line for simple clickjacking protection: 97 | # 'django.middleware.clickjacking.XFrameOptionsMiddleware', 98 | ) 99 | 100 | ROOT_URLCONF = 'demo.urls' 101 | 102 | # Python dotted path to the WSGI application used by Django's runserver. 103 | WSGI_APPLICATION = 'demo.wsgi.application' 104 | 105 | TEMPLATE_DIRS = ( 106 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 107 | # Always use forward slashes, even on Windows. 108 | # Don't forget to use absolute paths, not relative paths. 109 | ) 110 | 111 | INSTALLED_APPS = ( 112 | 'django.contrib.auth', 113 | 'django.contrib.contenttypes', 114 | 'django.contrib.sessions', 115 | 'django.contrib.sites', 116 | 'django.contrib.messages', 117 | 'django.contrib.staticfiles', 118 | # Uncomment the next line to enable the admin: 119 | # 'django.contrib.admin', 120 | # Uncomment the next line to enable admin documentation: 121 | # 'django.contrib.admindocs', 122 | 'echo', 123 | ) 124 | 125 | # A sample logging configuration. The only tangible logging 126 | # performed by this configuration is to send an email to 127 | # the site admins on every HTTP 500 error when DEBUG=False. 128 | # See http://docs.djangoproject.com/en/dev/topics/logging for 129 | # more details on how to customize your logging configuration. 130 | LOGGING = { 131 | 'version': 1, 132 | 'disable_existing_loggers': False, 133 | 'filters': { 134 | 'require_debug_false': { 135 | '()': 'django.utils.log.RequireDebugFalse' 136 | } 137 | }, 138 | 'handlers': { 139 | 'mail_admins': { 140 | 'level': 'ERROR', 141 | 'filters': ['require_debug_false'], 142 | 'class': 'django.utils.log.AdminEmailHandler' 143 | } 144 | }, 145 | 'loggers': { 146 | 'django.request': { 147 | 'handlers': ['mail_admins'], 148 | 'level': 'ERROR', 149 | 'propagate': True, 150 | }, 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /demo/demo/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | 3 | # Uncomment the next two lines to enable the admin: 4 | # from django.contrib import admin 5 | # admin.autodiscover() 6 | 7 | urlpatterns = patterns('', 8 | url(r'^wechat/', 'echo.views.wechat'), 9 | ) 10 | -------------------------------------------------------------------------------- /demo/demo/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for demo project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 19 | 20 | # This application object is used by any WSGI server configured to use this 21 | # file. This includes Django's development server, if the WSGI_APPLICATION 22 | # setting points here. 23 | from django.core.wsgi import get_wsgi_application 24 | application = get_wsgi_application() 25 | 26 | # Apply WSGI middleware here. 27 | # from helloworld.wsgi import HelloWorldApplication 28 | # application = HelloWorldApplication(application) 29 | -------------------------------------------------------------------------------- /demo/echo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffkit/wechat/ab5147c1d20c1a705cd257b519f9930e2fc6e12f/demo/echo/__init__.py -------------------------------------------------------------------------------- /demo/echo/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /demo/echo/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file demonstrates writing tests using the unittest module. These will pass 3 | when you run "manage.py test". 4 | 5 | Replace this with more appropriate tests for your application. 6 | """ 7 | 8 | from django.test import TestCase 9 | 10 | 11 | class SimpleTest(TestCase): 12 | def test_basic_addition(self): 13 | """ 14 | Tests that 1 + 1 always equals 2. 15 | """ 16 | self.assertEqual(1 + 1, 2) 17 | -------------------------------------------------------------------------------- /demo/echo/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 –*- 2 | # Create your views here. 3 | from django.http import HttpResponse 4 | from wechat.official import WxApplication, WxTextResponse 5 | from django.views.decorators.csrf import csrf_exempt 6 | 7 | class WxApp(WxApplication): 8 | """把用户输入的文本原样返回。 9 | """ 10 | 11 | SECRET_TOKEN = '' 12 | APP_ID = '' 13 | ENCODING_AES_KEY = '' 14 | 15 | def on_text(self, req): 16 | return WxTextResponse(req.Content, req) 17 | 18 | @csrf_exempt 19 | def wechat(request): 20 | app = WxApp() 21 | result = app.process(request.GET, request.body) 22 | return HttpResponse(result) 23 | -------------------------------------------------------------------------------- /demo/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | from wechat import VERSION 5 | 6 | url="https://github.com/jeffkit/wechat" 7 | 8 | long_description="Wechat Python SDK" 9 | 10 | setup(name="wechat", 11 | version=VERSION, 12 | description=long_description, 13 | maintainer="jeff kit", 14 | maintainer_email="bbmyth@gmail.com", 15 | url = url, 16 | long_description=long_description, 17 | install_requires = ['requests'], 18 | packages=find_packages('.'), 19 | ) 20 | 21 | 22 | -------------------------------------------------------------------------------- /tea.yaml: -------------------------------------------------------------------------------- 1 | # https://tea.xyz/what-is-this-file 2 | --- 3 | version: 1.0.0 4 | codeOwners: 5 | - '0xf096e3122C2E9Dd5D4F1D4206E07e60b3b1aDd1A' 6 | quorum: 1 7 | -------------------------------------------------------------------------------- /wechat/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding=utf-8 2 | VERSION = "0.4.17" 3 | -------------------------------------------------------------------------------- /wechat/crypt.py: -------------------------------------------------------------------------------- 1 | #encoding=utf-8 2 | 3 | import base64 4 | import string 5 | import random 6 | import hashlib 7 | import time 8 | import struct 9 | from Crypto.Cipher import AES 10 | import xml.etree.cElementTree as ET 11 | import sys 12 | import socket 13 | reload(sys) 14 | sys.setdefaultencoding('utf-8') 15 | 16 | WXBizMsgCrypt_OK = 0 17 | WXBizMsgCrypt_ValidateSignature_Error = -40001 18 | WXBizMsgCrypt_ParseXml_Error = -40002 19 | WXBizMsgCrypt_ComputeSignature_Error = -40003 20 | WXBizMsgCrypt_IllegalAesKey = -40004 21 | WXBizMsgCrypt_ValidateAppidOrCorpid_Error = -40005 22 | WXBizMsgCrypt_EncryptAES_Error = -40006 23 | WXBizMsgCrypt_DecryptAES_Error = -40007 24 | WXBizMsgCrypt_IllegalBuffer = -40008 25 | WXBizMsgCrypt_EncodeBase64_Error = -40009 26 | WXBizMsgCrypt_DecodeBase64_Error = -40010 27 | WXBizMsgCrypt_GenReturnXml_Error = -40011 28 | 29 | 30 | """ 31 | 关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案 32 | 请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。 33 | 下载后,按照README中的“Installation”小节的提示进行pycrypto安装。 34 | """ 35 | 36 | 37 | class FormatException(Exception): 38 | pass 39 | 40 | 41 | def throw_exception(message, exception_class=FormatException): 42 | """my define raise exception function""" 43 | raise exception_class(message) 44 | 45 | 46 | class SHA1: 47 | """计算公众平台的消息签名接口""" 48 | 49 | def getSHA1(self, token, timestamp, nonce, encrypt): 50 | """用SHA1算法生成安全签名 51 | @param token: 票据 52 | @param timestamp: 时间戳 53 | @param encrypt: 密文 54 | @param nonce: 随机字符串 55 | @return: 安全签名 56 | """ 57 | try: 58 | sortlist = [token, timestamp, nonce, encrypt] 59 | sortlist.sort() 60 | sha = hashlib.sha1() 61 | sha.update("".join(sortlist)) 62 | return WXBizMsgCrypt_OK, sha.hexdigest() 63 | except Exception: 64 | return WXBizMsgCrypt_ComputeSignature_Error, None 65 | 66 | 67 | class XMLParse: 68 | """提供提取消息格式中的密文及生成回复消息格式的接口""" 69 | AES_TEXT_RESPONSE_TEMPLATE = """ 70 | 71 | 72 | %(timestamp)s 73 | 74 | """ 75 | 76 | def extract(self, xmltext): 77 | """提取出xml数据包中的加密消息 78 | @param xmltext: 待提取的xml字符串 79 | @return: 提取出的加密消息字符串 80 | """ 81 | try: 82 | xml_tree = ET.fromstring(xmltext) 83 | encrypt = xml_tree.find("Encrypt") 84 | touser_name = xml_tree.find("ToUserName") 85 | if touser_name != None: 86 | touser_name = touser_name.text 87 | return WXBizMsgCrypt_OK, encrypt.text, touser_name 88 | except Exception: 89 | return WXBizMsgCrypt_ParseXml_Error, None, None 90 | 91 | def generate(self, encrypt, signature, timestamp, nonce): 92 | """生成xml消息 93 | @param encrypt: 加密后的消息密文 94 | @param signature: 安全签名 95 | @param timestamp: 时间戳 96 | @param nonce: 随机字符串 97 | @return: 生成的xml字符串 98 | """ 99 | resp_dict = { 100 | 'msg_encrypt': encrypt, 101 | 'msg_signaturet': signature, 102 | 'timestamp': timestamp, 103 | 'nonce': nonce, 104 | } 105 | resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict 106 | return resp_xml 107 | 108 | 109 | class PKCS7Encoder(): 110 | """提供基于PKCS7算法的加解密接口""" 111 | block_size = 32 112 | 113 | def encode(self, text): 114 | """ 对需要加密的明文进行填充补位 115 | @param text: 需要进行填充补位操作的明文 116 | @return: 补齐明文字符串 117 | """ 118 | text_length = len(text) 119 | # 计算需要填充的位数 120 | amount_to_pad = self.block_size - (text_length % self.block_size) 121 | if amount_to_pad == 0: 122 | amount_to_pad = self.block_size 123 | # 获得补位所用的字符 124 | pad = chr(amount_to_pad) 125 | return text + pad * amount_to_pad 126 | 127 | def decode(self, decrypted): 128 | """删除解密后明文的补位字符 129 | @param decrypted: 解密后的明文 130 | @return: 删除补位字符后的明文 131 | """ 132 | pad = ord(decrypted[-1]) 133 | if pad < 1 or pad > 32: 134 | pad = 0 135 | return decrypted[:-pad] 136 | 137 | 138 | class Prpcrypt(object): 139 | """提供接收和推送给公众平台消息的加解密接口""" 140 | 141 | def __init__(self, key): 142 | #self.key = base64.b64decode(key+"=") 143 | self.key = key 144 | # 设置加解密模式为AES的CBC模式 145 | self.mode = AES.MODE_CBC 146 | 147 | def encrypt(self, text, appid): 148 | """对明文进行加密 149 | @param text: 需要加密的明文 150 | @return: 加密得到的字符串 151 | """ 152 | # 16位随机字符串添加到明文开头 153 | text = self.get_random_str() + struct.pack( 154 | "I", socket.htonl(len(text))) + text + appid 155 | # 使用自定义的填充方式对明文进行补位填充 156 | pkcs7 = PKCS7Encoder() 157 | text = pkcs7.encode(text) 158 | # 加密 159 | cryptor = AES.new(self.key, self.mode, self.key[:16]) 160 | try: 161 | ciphertext = cryptor.encrypt(text) 162 | # 使用BASE64对加密后的字符串进行编码 163 | return WXBizMsgCrypt_OK, base64.b64encode(ciphertext) 164 | except Exception: 165 | return WXBizMsgCrypt_EncryptAES_Error, None 166 | 167 | def decrypt(self, text, appid): 168 | """对解密后的明文进行补位删除 169 | @param text: 密文 170 | @return: 删除填充补位后的明文 171 | """ 172 | try: 173 | cryptor = AES.new(self.key, self.mode, self.key[:16]) 174 | # 使用BASE64对密文进行解码,然后AES-CBC解密 175 | plain_text = cryptor.decrypt(base64.b64decode(text)) 176 | except Exception: 177 | return WXBizMsgCrypt_DecryptAES_Error, None 178 | try: 179 | pad = ord(plain_text[-1]) 180 | # 去掉补位字符串 181 | #pkcs7 = PKCS7Encoder() 182 | #plain_text = pkcs7.encode(plain_text) 183 | # 去除16位随机字符串 184 | content = plain_text[16:-pad] 185 | xml_len = socket.ntohl(struct.unpack("I", content[:4])[0]) 186 | xml_content = content[4:xml_len+4] 187 | from_appid = content[xml_len+4:] 188 | except Exception: 189 | return WXBizMsgCrypt_IllegalBuffer, None 190 | if from_appid != appid: 191 | return WXBizMsgCrypt_ValidateAppidOrCorpid_Error, None 192 | return 0, xml_content 193 | 194 | def get_random_str(self): 195 | """ 随机生成16位字符串 196 | @return: 16位字符串 197 | """ 198 | rule = string.letters + string.digits 199 | str = random.sample(rule, 16) 200 | return "".join(str) 201 | 202 | 203 | class WXBizMsgCrypt(object): 204 | def __init__(self, sToken, sEncodingAESKey, sCorpId): 205 | try: 206 | self.key = base64.b64decode(sEncodingAESKey+"=") 207 | assert len(self.key) == 32 208 | except: 209 | throw_exception("[error]: EncodingAESKey unvalid !", 210 | FormatException) 211 | #return WXBizMsgCrypt_IllegalAesKey) 212 | self.m_sToken = sToken 213 | self.m_sCorpid = sCorpId 214 | 215 | def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr): 216 | sha1 = SHA1() 217 | ret, signature = sha1.getSHA1(self.m_sToken, 218 | sTimeStamp, sNonce, sEchoStr) 219 | if ret != 0: 220 | return ret, None 221 | if not signature == sMsgSignature: 222 | return WXBizMsgCrypt_ValidateSignature_Error, None 223 | pc = Prpcrypt(self.key) 224 | ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sCorpid) 225 | return ret, sReplyEchoStr 226 | 227 | def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None): 228 | pc = Prpcrypt(self.key) 229 | ret, encrypt = pc.encrypt(sReplyMsg, self.m_sCorpid) 230 | if ret != 0: 231 | return ret, None 232 | if timestamp is None: 233 | timestamp = str(int(time.time())) 234 | # 生成安全签名 235 | sha1 = SHA1() 236 | ret, signature = sha1.getSHA1(self.m_sToken, timestamp, 237 | sNonce, encrypt) 238 | if ret != 0: 239 | return ret, None 240 | xmlParse = XMLParse() 241 | return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce) 242 | 243 | def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce): 244 | xmlParse = XMLParse() 245 | ret, encrypt, touser_name = xmlParse.extract(sPostData) 246 | if ret != 0: 247 | return ret, None 248 | sha1 = SHA1() 249 | ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, 250 | sNonce, encrypt) 251 | if ret != 0: 252 | return ret, None 253 | if not signature == sMsgSignature: 254 | return WXBizMsgCrypt_ValidateSignature_Error, None 255 | pc = Prpcrypt(self.key) 256 | ret, xml_content = pc.decrypt(encrypt, self.m_sCorpid) 257 | return ret, xml_content 258 | -------------------------------------------------------------------------------- /wechat/enterprise.py: -------------------------------------------------------------------------------- 1 | #encoding=utf-8 2 | 3 | import requests 4 | import time 5 | import urllib 6 | from .models import WxRequest, WxResponse 7 | from .models import WxArticle, WxImage, WxVoice, WxVideo, WxLink 8 | from .models import WxTextResponse, WxImageResponse, WxVoiceResponse,\ 9 | WxVideoResponse, WxNewsResponse, APIError, WxEmptyResponse 10 | from .official import WxApplication as BaseApplication, WxBaseApi 11 | from .crypt import WXBizMsgCrypt 12 | 13 | __all__ = ['WxRequest', 'WxResponse', 'WxArticle', 'WxImage', 14 | 'WxVoice', 'WxVideo', 'WxLink', 'WxTextResponse', 15 | 'WxImageResponse', 'WxVoiceResponse', 'WxVideoResponse', 16 | 'WxNewsResponse', 'WxApplication', 17 | 'WxApi', 'APIError'] 18 | 19 | 20 | class WxApplication(BaseApplication): 21 | 22 | UNSUPPORT_TXT = u'暂不支持此类型消息' 23 | WELCOME_TXT = u'你好!感谢您的关注!' 24 | SECRET_TOKEN = None 25 | CORP_ID = None 26 | ENCODING_AES_KEY = None 27 | 28 | def process(self, params, xml=None, token=None, corp_id=None, 29 | aes_key=None): 30 | self.token = token or self.SECRET_TOKEN 31 | self.corp_id = corp_id or self.CORP_ID 32 | self.aes_key = aes_key or self.ENCODING_AES_KEY 33 | 34 | assert self.token is not None 35 | assert self.corp_id is not None 36 | assert self.aes_key is not None 37 | 38 | timestamp = params.get('timestamp', '') 39 | nonce = params.get('nonce', '') 40 | msg_signature = params.get('msg_signature', '') 41 | echostr = params.get('echostr', '') 42 | 43 | cpt = WXBizMsgCrypt(self.token, self.aes_key, self.corp_id) 44 | 45 | if not xml: 46 | err, echo = cpt.VerifyURL(msg_signature, timestamp, nonce, echostr) 47 | if err: 48 | return 'invalid request, code %s' % err 49 | else: 50 | return echo 51 | 52 | err, xml = cpt.DecryptMsg(xml, msg_signature, timestamp, nonce) 53 | if err: 54 | return 'decrypt message error , code %s' % err 55 | 56 | self.req = WxRequest(xml) 57 | func = self.handler_map().get(self.req.MsgType, None) 58 | if not func: 59 | return WxEmptyResponse() 60 | self.pre_process() 61 | rsp = func(self.req) 62 | self.post_process() 63 | result = rsp.as_xml().encode('UTF-8') 64 | 65 | if not result: 66 | return '' 67 | 68 | err, result = cpt.EncryptMsg(result, nonce) 69 | if err: 70 | return 'encrypt message error , code %s' % err 71 | return result 72 | 73 | 74 | def format_list(data): 75 | if data and (isinstance(data, list) or isinstance(data, tuple)): 76 | return '|'.join(data) 77 | else: 78 | return data 79 | 80 | 81 | def simplify_send_parmas(params): 82 | keys = params.keys() 83 | for key in keys: 84 | if not params[key]: 85 | del params[key] 86 | return params 87 | 88 | 89 | class WxApi(WxBaseApi): 90 | 91 | API_PREFIX = 'https://qyapi.weixin.qq.com/' 92 | 93 | def __init__(self, appid, appsecret, api_entry=None): 94 | super(WxApi, self).__init__(appid, appsecret, api_entry) 95 | self.expires_in = time.time() 96 | 97 | @property 98 | def access_token(self): 99 | if self._access_token and time.time() >= self.expires_in: 100 | self._access_token = None 101 | 102 | if not self._access_token: 103 | token, err = self.get_access_token() 104 | if not err: 105 | self._access_token = token['access_token'] 106 | self.expires_in = time.time() + token['expires_in'] 107 | return self._access_token 108 | else: 109 | return None 110 | return self._access_token 111 | 112 | def get_access_token(self, url=None, **kwargs): 113 | params = {'corpid': self.appid, 'corpsecret': self.appsecret} 114 | params.update(kwargs) 115 | rsp = requests.get(url or self.api_entry + 'cgi-bin/gettoken', 116 | params=params, 117 | verify=False) 118 | return self._process_response(rsp) 119 | 120 | def departments(self): 121 | return self._get('cgi-bin/department/list') 122 | 123 | def add_department(self, name, parentid='1', order=None): 124 | return self._post('cgi-bin/department/create', 125 | params={'name': name, 'parentid': parentid, 126 | 'order': order}) 127 | 128 | def update_department(self, depid, name=None, parentid=None, order=None): 129 | return self._post('cgi-bin/department/update', 130 | params={'id': depid, 'name': name, 131 | 'parentid': parentid, 'order': order}) 132 | 133 | def delete_department(self, depid): 134 | return self._get('cgi-bin/department/delete', params={'id': depid}) 135 | 136 | def tags(self): 137 | return self._get('cgi-bin/tag/list') 138 | 139 | def add_tag(self, tagname): 140 | return self._post('cgi-bin/tag/create', {'tagname': tagname}) 141 | 142 | def update_tag(self, tagid, tagname): 143 | return self._post('cgi-bin/tag/update', 144 | {'tagid': tagid, 'tagname': tagname}) 145 | 146 | def delete_tag(self, tagid): 147 | return self._get('cgi-bin/tag/delete', params={'tagid': tagid}) 148 | 149 | def tag_users(self, tagid): 150 | return self._get('cgi-bin/tag/get', params={'tagid': tagid}) 151 | 152 | def add_tag_user(self, tagid, userlist): 153 | return self._post('cgi-bin/tag/addtagusers', 154 | {'tagid': tagid, 'userlist': userlist}) 155 | 156 | def remove_tag_user(self, tagid, userlist): 157 | return self._post('cgi-bin/tag/deltagusers', 158 | {'tagid': tagid, 'userlist': userlist}) 159 | 160 | def department_users(self, department_id, fetch_child=0, status=0): 161 | return self._get('cgi-bin/user/simplelist', 162 | params={'department_id': department_id, 163 | 'fetch_child': fetch_child, 164 | 'status': status}) 165 | 166 | def add_user(self, userid, name, department=None, position=None, 167 | mobile=None, gender=None, tel=None, email=None, 168 | weixinid=None, extattr=None): 169 | params = { 170 | "userid": userid, 171 | "name": name, 172 | "department": department, 173 | "position": position, 174 | "mobile": mobile, 175 | "gender": gender, 176 | "tel": tel, 177 | "email": email, 178 | "weixinid": weixinid, 179 | "extattr": extattr, 180 | } 181 | return self._post('cgi-bin/user/create', params) 182 | 183 | def update_user(self, userid, name, department=None, position=None, 184 | mobile=None, gender=None, tel=None, email=None, 185 | weixinid=None, extattr=None): 186 | params = { 187 | "userid": userid, 188 | "name": name, 189 | "department": department, 190 | "position": position, 191 | "mobile": mobile, 192 | "gender": gender, 193 | "tel": tel, 194 | "email": email, 195 | "weixinid": weixinid, 196 | "extattr": extattr, 197 | } 198 | return self._post('cgi-bin/user/update', params) 199 | 200 | def delete_user(self, userid): 201 | return self._get('cgi-bin/user/delete', 202 | params={'userid': userid}) 203 | 204 | def get_user(self, userid): 205 | return self._get('cgi-bin/user/get', 206 | params={'userid': userid}) 207 | 208 | def upload_media(self, mtype, file_path=None, file_content=None): 209 | return super(WxApi, self).upload_media( 210 | mtype, file_path=file_path, file_content=file_content, 211 | url='cgi-bin/media/upload', 212 | suffies={'image': '.jpg', 'voice': '.mp3', 213 | 'video': '.mp4', 'file': ''}) 214 | 215 | def download_media(self, media_id, to_path): 216 | return super(WxApi, self).download_media( 217 | media_id, to_path, 'cgi-bin/media/get') 218 | 219 | def send_message(self, msg_type, content, agentid, safe="0", touser=None, 220 | toparty=None, totag=None, **kwargs): 221 | func = {'text': self.send_text, 222 | 'image': self.send_image, 223 | 'voice': self.send_voice, 224 | 'video': self.send_video, 225 | 'file': self.send_file, 226 | 'news': self.send_news, 227 | 'mpnews': self.send_mpnews}.get(msg_type, None) 228 | if func: 229 | return func(content, agentid, safe=safe, touser=touser, 230 | toparty=toparty, totag=totag, **kwargs) 231 | else: 232 | return None, None 233 | 234 | def send_text(self, content, agentid, safe="0", touser=None, 235 | toparty=None, totag=None): 236 | return self._post( 237 | 'cgi-bin/message/send', 238 | simplify_send_parmas({'touser': format_list(touser), 239 | 'toparty': format_list(toparty), 240 | 'totag': format_list(totag), 241 | 'msgtype': 'text', 242 | 'agentid': agentid, 243 | 'safe': safe, 244 | 'text': {'content': content} 245 | })) 246 | 247 | def send_simple_media(self, mtype, media_id, agentid, safe="0", 248 | touser=None, toparty=None, totag=None, 249 | media_url=None): 250 | if media_id and media_id.startswith('http'): 251 | media_url = media_id 252 | media_id = None 253 | mid = self._get_media_id( 254 | {'media_id': media_id, 'media_url': media_url}, 255 | 'media', mtype) 256 | return self._post( 257 | 'cgi-bin/message/send', 258 | simplify_send_parmas({'touser': format_list(touser), 259 | 'toparty': format_list(toparty), 260 | 'totag': format_list(totag), 261 | 'msgtype': mtype, 262 | 'agentid': agentid, 263 | 'safe': safe, 264 | mtype: {'media_id': mid} 265 | })) 266 | 267 | def send_image(self, media_id, agentid, safe="0", touser=None, 268 | toparty=None, totag=None, media_url=None): 269 | return self.send_simple_media('image', media_id, agentid, safe, 270 | touser, toparty, totag, media_url) 271 | 272 | def send_voice(self, media_id, agentid, safe="0", touser=None, 273 | toparty=None, totag=None, media_url=None): 274 | return self.send_simple_media('voice', media_id, agentid, safe, 275 | touser, toparty, totag, media_url) 276 | 277 | def send_file(self, media_id, agentid, safe="0", touser=None, 278 | toparty=None, totag=None, media_url=None): 279 | return self.send_simple_media('file', media_id, agentid, safe, 280 | touser, toparty, totag, media_url) 281 | 282 | def send_video(self, video, agentid, safe="0", touser=None, 283 | toparty=None, totag=None, media_url=None): 284 | video['media_id'] = self._get_media_id(video, 'media', 'video') 285 | if 'media_url' in video: 286 | del video['media_url'] 287 | return self._post( 288 | 'cgi-bin/message/send', 289 | simplify_send_parmas({'touser': format_list(touser), 290 | 'toparty': format_list(toparty), 291 | 'totag': format_list(totag), 292 | 'msgtype': 'video', 293 | 'agentid': agentid, 294 | 'safe': safe, 295 | 'video': video})) 296 | 297 | def send_news(self, news, agentid, safe="0", touser=None, 298 | toparty=None, totag=None, media_url=None): 299 | if isinstance(news, dict): 300 | news = [news] 301 | return self._post( 302 | 'cgi-bin/message/send', 303 | simplify_send_parmas({'touser': format_list(touser), 304 | 'toparty': format_list(toparty), 305 | 'totag': format_list(totag), 306 | 'msgtype': 'news', 307 | 'agentid': agentid, 308 | 'safe': safe, 309 | 'news': {'articles': news}})) 310 | 311 | def send_mpnews(self, mpnews, agentid, safe="0", touser=None, 312 | toparty=None, totag=None, media_url=None): 313 | if isinstance(mpnews, dict): 314 | news = [mpnews] 315 | return self._post( 316 | 'cgi-bin/message/send', 317 | simplify_send_parmas({'touser': format_list(touser), 318 | 'toparty': format_list(toparty), 319 | 'totag': format_list(totag), 320 | 'msgtype': 'mpnews', 321 | 'agentid': agentid, 322 | 'safe': safe, 323 | 'mpnews': {'articles': news}})) 324 | 325 | def create_menu(self, menus, agentid): 326 | return self._post('cgi-bin/menu/create?agentid=%s' % agentid, 327 | repr(menus), ctype='text') 328 | 329 | def get_menu(self, agentid): 330 | return self._get('cgi-bin/menu/get', {'agentid': agentid}) 331 | 332 | def delete_menu(self, agentid): 333 | return self._get('cgi-bin/menu/delete', {'agentid': agentid}) 334 | 335 | # OAuth2 336 | def authorize_url(self, appid, redirect_uri, response_type='code', 337 | scope='snsapi_base', state=None): 338 | # 变态的微信实现,参数的顺序也有讲究。。艹!这个实现太恶心,太恶心! 339 | url = 'https://open.weixin.qq.com/connect/oauth2/authorize?' 340 | rd_uri = urllib.urlencode({'redirect_uri': redirect_uri}) 341 | url += 'appid=%s&' % appid 342 | url += rd_uri 343 | url += '&response_type=' + response_type 344 | url += '&scope=' + scope 345 | if state: 346 | url += '&state=' + state 347 | return url + '#wechat_redirect' 348 | 349 | def get_user_info(self, agentid, code): 350 | return self._get('cgi-bin/user/getuserinfo', 351 | {'agentid': agentid, 'code': code}) 352 | -------------------------------------------------------------------------------- /wechat/models.py: -------------------------------------------------------------------------------- 1 | #encoding=utf-8 2 | 3 | from xml.dom import minidom 4 | import collections 5 | import time 6 | 7 | 8 | def kv2element(key, value, doc): 9 | ele = doc.createElement(key) 10 | if isinstance(value, str) or isinstance(value, unicode): 11 | data = doc.createCDATASection(value) 12 | ele.appendChild(data) 13 | else: 14 | text = doc.createTextNode(str(value)) 15 | ele.appendChild(text) 16 | return ele 17 | 18 | 19 | def fields2elements(tupleObj, enclose_tag=None, doc=None): 20 | if enclose_tag: 21 | xml = doc.createElement(enclose_tag) 22 | for key in tupleObj._fields: 23 | ele = kv2element(key, getattr(tupleObj, key), doc) 24 | xml.appendChild(ele) 25 | return xml 26 | else: 27 | return [kv2element(key, getattr(tupleObj, key), doc) 28 | for key in tupleObj._fields] 29 | 30 | 31 | class WxRequest(object): 32 | 33 | def __init__(self, xml=None): 34 | if not xml: 35 | return 36 | doc = minidom.parseString(xml) 37 | params = [ele for ele in doc.childNodes[0].childNodes 38 | if isinstance(ele, minidom.Element)] 39 | for param in params: 40 | if param.childNodes: 41 | text = param.childNodes[0] 42 | self.__dict__.update({param.tagName: text.data}) 43 | else: 44 | self.__dict__.update({param.tagName: ''}) 45 | 46 | 47 | class WxResponse(object): 48 | 49 | def __init__(self, request): 50 | self.CreateTime = long(time.time()) 51 | self.ToUserName = request.FromUserName 52 | self.FromUserName = request.ToUserName 53 | self.Extra = {} 54 | 55 | def as_xml(self): 56 | doc = minidom.Document() 57 | xml = doc.createElement('xml') 58 | doc.appendChild(xml) 59 | xml.appendChild(kv2element('ToUserName', self.ToUserName, doc)) 60 | xml.appendChild(kv2element('FromUserName', self.FromUserName, doc)) 61 | xml.appendChild(kv2element('CreateTime', self.CreateTime, doc)) 62 | xml.appendChild(kv2element('MsgType', self.MsgType, doc)) 63 | contents = self.content_nodes(doc) 64 | if isinstance(contents, list) or isinstance(contents, tuple): 65 | for content in contents: 66 | xml.appendChild(content) 67 | else: 68 | xml.appendChild(contents) 69 | if self.Extra: 70 | for key, value in self.Extra.iteritems(): 71 | xml.appendChild(kv2element(key, value, doc)) 72 | return doc.toxml() 73 | 74 | 75 | WxMusic = collections.namedtuple('WxMusic', 76 | 'Title Description MusicUrl HQMusicUrl') 77 | WxArticle = collections.namedtuple('WxArticle', 78 | 'Title Description PicUrl Url') 79 | WxImage = collections.namedtuple('WxImage', 'MediaId') 80 | WxVoice = collections.namedtuple('WxVoice', 'MediaId') 81 | WxVideo = collections.namedtuple('WxVideo', 82 | 'MediaId Title Description') 83 | WxLink = collections.namedtuple('WxLink', 'Title Description Url') 84 | 85 | 86 | class WxEmptyResponse(WxResponse): 87 | 88 | def __init__(self): 89 | pass 90 | 91 | def as_xml(self): 92 | return '' 93 | 94 | 95 | class WxTextResponse(WxResponse): 96 | 97 | MsgType = 'text' 98 | 99 | def __init__(self, text, request): 100 | super(WxTextResponse, self).__init__(request) 101 | self.text = text 102 | 103 | def content_nodes(self, doc): 104 | return kv2element('Content', self.text, doc) 105 | 106 | 107 | class WxCompoundResponse(WxResponse): 108 | 109 | MsgType = '' 110 | Tag = '' 111 | 112 | def __init__(self, data, request): 113 | super(WxCompoundResponse, self).__init__(request) 114 | self.data = data 115 | 116 | def content_nodes(self, doc): 117 | return fields2elements(self.data, self.Tag, doc) 118 | 119 | 120 | class WxImageResponse(WxCompoundResponse): 121 | 122 | MsgType = 'image' 123 | Tag = 'Image' 124 | 125 | 126 | class WxVoiceResponse(WxCompoundResponse): 127 | 128 | MsgType = 'voice' 129 | Tag = 'Voice' 130 | 131 | 132 | class WxVideoResponse(WxCompoundResponse): 133 | 134 | MsgType = 'video' 135 | Tag = 'Video' 136 | 137 | 138 | class WxMusicResponse(WxResponse): 139 | 140 | MsgType = 'music' 141 | Tag = 'Music' 142 | 143 | 144 | class WxNewsResponse(WxResponse): 145 | 146 | MsgType = 'news' 147 | 148 | def __init__(self, articles, request): 149 | super(WxNewsResponse, self).__init__(request) 150 | if isinstance(articles, list) or isinstance(articles, tuple): 151 | self.articles = articles 152 | else: 153 | self.articles = [articles] 154 | 155 | def content_nodes(self, doc): 156 | count = kv2element('ArticleCount', len(self.articles), doc) 157 | articles = doc.createElement('Articles') 158 | for article in self.articles: 159 | articles.appendChild(fields2elements(article, 'item', doc)) 160 | return count, articles 161 | 162 | 163 | class APIError(object): 164 | 165 | def __init__(self, code, msg): 166 | self.code = code 167 | self.message = msg 168 | -------------------------------------------------------------------------------- /wechat/official.py: -------------------------------------------------------------------------------- 1 | # encoding=utf-8 2 | 3 | from hashlib import sha1 4 | import requests 5 | import json 6 | import tempfile 7 | import shutil 8 | import os 9 | from .crypt import WXBizMsgCrypt 10 | 11 | from .models import WxRequest, WxResponse 12 | from .models import WxMusic, WxArticle, WxImage, WxVoice, WxVideo, WxLink 13 | from .models import WxTextResponse, WxImageResponse, WxVoiceResponse,\ 14 | WxVideoResponse, WxMusicResponse, WxNewsResponse, APIError, WxEmptyResponse 15 | 16 | __all__ = ['WxRequest', 'WxResponse', 'WxMusic', 'WxArticle', 'WxImage', 17 | 'WxVoice', 'WxVideo', 'WxLink', 'WxTextResponse', 18 | 'WxImageResponse', 'WxVoiceResponse', 'WxVideoResponse', 19 | 'WxMusicResponse', 'WxNewsResponse', 'WxApplication', 20 | 'WxEmptyResponse', 'WxApi', 'APIError'] 21 | 22 | 23 | class WxApplication(object): 24 | 25 | UNSUPPORT_TXT = u'暂不支持此类型消息' 26 | WELCOME_TXT = u'你好!感谢您的关注!' 27 | SECRET_TOKEN = None 28 | APP_ID = None 29 | ENCODING_AES_KEY = None 30 | 31 | def is_valid_params(self, params): 32 | timestamp = params.get('timestamp', '') 33 | nonce = params.get('nonce', '') 34 | signature = params.get('signature', '') 35 | echostr = params.get('echostr', '') 36 | 37 | sign_ele = [self.token, timestamp, nonce] 38 | sign_ele.sort() 39 | if(signature == sha1(''.join(sign_ele)).hexdigest()): 40 | return True, echostr 41 | else: 42 | return None 43 | 44 | def process(self, params, xml=None, token=None, app_id=None, aes_key=None): 45 | self.token = token if token else self.SECRET_TOKEN 46 | self.app_id = app_id if app_id else self.APP_ID 47 | self.aes_key = aes_key if aes_key else self.ENCODING_AES_KEY 48 | assert self.token is not None 49 | 50 | ret = self.is_valid_params(params) 51 | 52 | if not ret: 53 | return 'invalid request' 54 | if not xml: 55 | # 微信开发者设置的调用测试 56 | return ret[1] 57 | 58 | # 解密消息 59 | encrypt_type = params.get('encrypt_type', '') 60 | if encrypt_type != '' and encrypt_type != 'raw': 61 | msg_signature = params.get('msg_signature', '') 62 | timestamp = params.get('timestamp', '') 63 | nonce = params.get('nonce', '') 64 | if encrypt_type == 'aes': 65 | cpt = WXBizMsgCrypt(self.token, 66 | self.aes_key, self.app_id) 67 | err, xml = cpt.DecryptMsg(xml, msg_signature, timestamp, nonce) 68 | if err: 69 | return 'decrypt message error, code : %s' % err 70 | else: 71 | return 'unsupport encrypty type %s' % encrypt_type 72 | 73 | req = WxRequest(xml) 74 | self.wxreq = req 75 | func = self.handler_map().get(req.MsgType, None) 76 | if not func: 77 | return WxTextResponse(self.UNSUPPORT_TXT, req) 78 | self.pre_process() 79 | rsp = func(req) 80 | self.post_process(rsp) 81 | result = rsp.as_xml().encode('UTF-8') 82 | 83 | # 加密消息 84 | if encrypt_type != '' and encrypt_type != 'raw': 85 | if encrypt_type == 'aes': 86 | err, result = cpt.EncryptMsg(result, nonce) 87 | if err: 88 | return 'encrypt message error , code %s' % err 89 | else: 90 | return 'unsupport encrypty type %s' % encrypt_type 91 | return result 92 | 93 | def on_text(self, text): 94 | return WxTextResponse(self.UNSUPPORT_TXT, text) 95 | 96 | def on_link(self, link): 97 | return WxTextResponse(self.UNSUPPORT_TXT, link) 98 | 99 | def on_image(self, image): 100 | return WxTextResponse(self.UNSUPPORT_TXT, image) 101 | 102 | def on_voice(self, voice): 103 | return WxTextResponse(self.UNSUPPORT_TXT, voice) 104 | 105 | def on_video(self, video): 106 | return WxTextResponse(self.UNSUPPORT_TXT, video) 107 | 108 | def on_location(self, loc): 109 | return WxTextResponse(self.UNSUPPORT_TXT, loc) 110 | 111 | def event_map(self): 112 | if getattr(self, 'event_handlers', None): 113 | return self.event_handlers 114 | return { 115 | 'subscribe': self.on_subscribe, 116 | 'unsubscribe': self.on_unsubscribe, 117 | 'SCAN': self.on_scan, 118 | 'LOCATION': self.on_location_update, 119 | 'CLICK': self.on_click, 120 | 'VIEW': self.on_view, 121 | 'scancode_push': self.on_scancode_push, 122 | 'scancode_waitmsg': self.on_scancode_waitmsg, 123 | 'pic_sysphoto': self.on_pic_sysphoto, 124 | 'pic_photo_or_album': self.on_pic_photo_or_album, 125 | 'pic_weixin': self.on_pic_weixin, 126 | 'location_select': self.on_location_select, 127 | } 128 | 129 | def on_event(self, event): 130 | func = self.event_map().get(event.Event, None) 131 | return func(event) 132 | 133 | def on_subscribe(self, sub): 134 | return WxTextResponse(self.WELCOME_TXT, sub) 135 | 136 | def on_unsubscribe(self, unsub): 137 | return WxEmptyResponse() 138 | 139 | def on_click(self, click): 140 | return WxEmptyResponse() 141 | 142 | def on_scan(self, scan): 143 | return WxEmptyResponse() 144 | 145 | def on_location_update(self, location): 146 | return WxEmptyResponse() 147 | 148 | def on_view(self, view): 149 | return WxEmptyResponse() 150 | 151 | def on_scancode_push(self, event): 152 | return WxEmptyResponse() 153 | 154 | def on_scancode_waitmsg(self, event): 155 | return WxEmptyResponse() 156 | 157 | def on_pic_sysphoto(self, event): 158 | return WxEmptyResponse() 159 | 160 | def on_pic_photo_or_album(self, event): 161 | return WxEmptyResponse() 162 | 163 | def on_pic_weixin(self, event): 164 | return WxEmptyResponse() 165 | 166 | def on_location_select(self, event): 167 | return WxEmptyResponse() 168 | 169 | def handler_map(self): 170 | if getattr(self, 'handlers', None): 171 | return self.handlers 172 | return { 173 | 'text': self.on_text, 174 | 'link': self.on_link, 175 | 'image': self.on_image, 176 | 'voice': self.on_voice, 177 | 'video': self.on_video, 178 | 'location': self.on_location, 179 | 'event': self.on_event, 180 | } 181 | 182 | def pre_process(self): 183 | pass 184 | 185 | def post_process(self, rsp=None): 186 | pass 187 | 188 | 189 | class WxBaseApi(object): 190 | 191 | API_PREFIX = 'https://api.weixin.qq.com/cgi-bin/' 192 | 193 | def __init__(self, appid, appsecret, api_entry=None): 194 | self.appid = appid 195 | self.appsecret = appsecret 196 | self._access_token = None 197 | self.api_entry = api_entry or self.API_PREFIX 198 | 199 | @property 200 | def access_token(self): 201 | if not self._access_token: 202 | token, err = self.get_access_token() 203 | if not err: 204 | self._access_token = token['access_token'] 205 | return self._access_token 206 | else: 207 | return None 208 | return self._access_token 209 | 210 | def set_access_token(self, token): 211 | self._access_token = token 212 | 213 | def _process_response(self, rsp): 214 | if rsp.status_code != 200: 215 | return None, APIError(rsp.status_code, 'http error') 216 | try: 217 | content = rsp.json() 218 | except: 219 | return None, APIError(99999, 'invalid rsp') 220 | if 'errcode' in content and content['errcode'] != 0: 221 | return None, APIError(content['errcode'], content['errmsg']) 222 | return content, None 223 | 224 | def _get(self, path, params=None): 225 | if not params: 226 | params = {} 227 | params['access_token'] = self.access_token 228 | rsp = requests.get(self.api_entry + path, params=params, 229 | verify=False) 230 | return self._process_response(rsp) 231 | 232 | def _post(self, path, data, ctype='json'): 233 | headers = {'Content-type': 'application/json'} 234 | path = self.api_entry + path 235 | if '?' in path: 236 | path += '&access_token=' + self.access_token 237 | else: 238 | path += '?access_token=' + self.access_token 239 | if ctype == 'json': 240 | data = json.dumps(data, ensure_ascii=False).encode('utf-8') 241 | rsp = requests.post(path, data=data, headers=headers, verify=False) 242 | return self._process_response(rsp) 243 | 244 | def upload_media(self, mtype, file_path=None, file_content=None, 245 | url='media/upload', suffies=None): 246 | path = self.api_entry + url + '?access_token=' \ 247 | + self.access_token + '&type=' + mtype 248 | suffies = suffies or {'image': '.jpg', 'voice': '.mp3', 249 | 'video': 'mp4', 'thumb': 'jpg'} 250 | suffix = None 251 | if mtype in suffies: 252 | suffix = suffies[mtype] 253 | if file_path: 254 | fd, tmp_path = tempfile.mkstemp(suffix=suffix) 255 | shutil.copy(file_path, tmp_path) 256 | os.close(fd) 257 | elif file_content: 258 | fd, tmp_path = tempfile.mkstemp(suffix=suffix) 259 | f = os.fdopen(fd, 'wb') 260 | f.write(file_content) 261 | f.close() 262 | media = open(tmp_path, 'rb') 263 | rsp = requests.post(path, files={'media': media}, 264 | verify=False) 265 | media.close() 266 | os.remove(tmp_path) 267 | return self._process_response(rsp) 268 | 269 | def download_media(self, media_id, to_path, url='media/get'): 270 | rsp = requests.get(self.api_entry + url, 271 | params={'media_id': media_id, 272 | 'access_token': self.access_token}, 273 | verify=False) 274 | if rsp.status_code == 200: 275 | save_file = open(to_path, 'wb') 276 | save_file.write(rsp.content) 277 | save_file.close() 278 | return {'errcode': 0}, None 279 | else: 280 | return None, APIError(rsp.status_code, 'http error') 281 | 282 | def _get_media_id(self, obj, resource, content_type): 283 | if not obj.get(resource + '_id'): 284 | rsp, err = None, None 285 | if obj.get(resource + '_content'): 286 | rsp, err = self.upload_media( 287 | content_type, 288 | file_content=obj.get(resource + '_content')) 289 | if err: 290 | return None 291 | elif obj.get(resource + '_url'): 292 | rs = requests.get(obj.get(resource + '_url')) 293 | rsp, err = self.upload_media( 294 | content_type, 295 | file_content=rs.content) 296 | if err: 297 | return None 298 | else: 299 | return None 300 | return rsp['media_id'] 301 | return obj.get(resource + '_id') 302 | 303 | 304 | class WxApi(WxBaseApi): 305 | 306 | def get_access_token(self, url=None, **kwargs): 307 | params = {'grant_type': 'client_credential', 'appid': self.appid, 308 | 'secret': self.appsecret} 309 | if kwargs: 310 | params.update(kwargs) 311 | rsp = requests.get(url or self.api_entry + 'token', params=params, 312 | verify=False) 313 | return self._process_response(rsp) 314 | 315 | def user_info(self, user_id, lang='zh_CN'): 316 | return self._get('user/info', {'openid': user_id, 'lang': lang}) 317 | 318 | def followers(self, next_id=''): 319 | return self._get('user/get', {'next_openid': next_id}) 320 | 321 | def send_message(self, to_user, msg_type, content): 322 | func = {'text': self.send_text, 323 | 'image': self.send_image, 324 | 'voice': self.send_voice, 325 | 'video': self.send_video, 326 | 'music': self.send_music, 327 | 'news': self.send_news}.get(msg_type, None) 328 | if func: 329 | return func(to_user, content) 330 | return None, None 331 | 332 | def send_text(self, to_user, content): 333 | return self._post('message/custom/send', 334 | {'touser': to_user, 'msgtype': 'text', 335 | 'text': {'content': content}}) 336 | 337 | def send_image(self, to_user, media_id=None, media_url=None): 338 | if media_id and media_id.startswith('http'): 339 | media_url = media_id 340 | media_id = None 341 | mid = self._get_media_id( 342 | {'media_id': media_id, 'media_url': media_url}, 343 | 'media', 'image') 344 | return self._post('message/custom/send', 345 | {'touser': to_user, 'msgtype': 'image', 346 | 'image': {'media_id': mid}}) 347 | 348 | def send_voice(self, to_user, media_id=None, media_url=None): 349 | if media_id and media_id.startswith('http'): 350 | media_url = media_id 351 | media_id = None 352 | mid = self._get_media_id( 353 | {'media_id': media_id, 'media_url': media_url}, 354 | 'media', 'voice') 355 | return self._post('message/custom/send', 356 | {'touser': to_user, 'msgtype': 'voice', 357 | 'voice': {'media_id': mid}}) 358 | 359 | def send_music(self, to_user, music): 360 | music['thumb_media_id'] = self._get_media_id(music, 361 | 'thumb_media', 362 | 'image') 363 | if not music.get('thumb_media_id'): 364 | return None, APIError(41006, 'missing media_id') 365 | return self._post('message/custom/send', 366 | {'touser': to_user, 'msgtype': 'music', 367 | 'music': music}) 368 | 369 | def send_video(self, to_user, video): 370 | video['media_id'] = self._get_media_id(video, 'media', 'video') 371 | video['thumb_media_id'] = self._get_media_id(video, 372 | 'thumb_media', 'image') 373 | if 'media_id' not in video or 'thumb_media_id' not in video: 374 | return None, APIError(41006, 'missing media_id') 375 | return self._post('message/custom/send', 376 | {'touser': to_user, 'msgtype': 'video', 377 | 'video': video}) 378 | 379 | def send_news(self, to_user, news): 380 | if isinstance(news, dict): 381 | news = [news] 382 | return self._post('message/custom/send', 383 | {'touser': to_user, 'msgtype': 'news', 384 | 'news': {'articles': news}}) 385 | 386 | def create_group(self, name): 387 | return self._post('groups/create', 388 | {'group': {'name': name}}) 389 | 390 | def groups(self): 391 | return self._get('groups/get') 392 | 393 | def update_group(self, group_id, name): 394 | return self._post('groups/update', 395 | {'group': {'id': group_id, 'name': name}}) 396 | 397 | def group_of_user(self, user_id): 398 | return self._get('groups/getid', {'openid': user_id}) 399 | 400 | def move_user_to_group(self, user_id, group_id): 401 | return self._post('groups/members/update', 402 | {'openid': user_id, 'to_groupid': group_id}) 403 | 404 | def create_menu(self, menus): 405 | return self._post('menu/create', menus) 406 | 407 | def get_menu(self): 408 | return self._get('menu/get') 409 | 410 | def delete_menu(self): 411 | return self._get('menu/delete') 412 | 413 | def create_tag(self, name): 414 | return self._post('tags/create', 415 | {'tag': {"name":name}}) 416 | 417 | def tags(self): 418 | return self._get('tags/get') 419 | 420 | def update_tag(self, tag_id,name): 421 | return self._post('tags/update', 422 | {'tag': {'id': tag_id, 'name': name}}) 423 | 424 | def delete_tag(self, tag_id): 425 | return self._post('tags/delete', 426 | {'tag': {'id': tag_id}}) 427 | 428 | def tag_of_user(self, user_id): 429 | return self._post('tags/getidlist', {'openid': user_id}) 430 | 431 | def batch_tagging(self, tag_id, users_list): 432 | return self._post('tags/members/batchtagging', 433 | {'openid_list': users_list, 'tagid': tag_id}) 434 | 435 | def batch_untagging(self, tag_id,users_list): 436 | return self._post('tags/members/batchuntagging', 437 | {'openid_list': users_list, 'tagid': tag_id}) 438 | 439 | def get_blacklist(self, user_id=""): 440 | return self._post('tags/members/getblacklist', 441 | {'begin_openid': user_id}) 442 | 443 | def batch_blacklist(self, users_list): 444 | return self._post('tags/members/batchblacklist', 445 | {'openid_list': users_list}) 446 | 447 | def batch_unblacklist(self, users_list): 448 | return self._post('tags/members/batchunblacklist', 449 | {'openid_list': users_list}) 450 | 451 | def update_user_remark(self, openid, remark): 452 | return self._post('user/info/updateremark', 453 | {'openid': openid, 'remark': remark}) 454 | 455 | def customservice_records(self, starttime, endtime, openid=None, 456 | pagesize=100, pageindex=1): 457 | return self._get('customservice/getrecord', 458 | {'starttime': starttime, 459 | 'endtime': endtime, 460 | 'openid': openid, 461 | 'pagesize': pagesize, 462 | 'pageindex': pageindex}) 463 | --------------------------------------------------------------------------------